mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-06 05:42:37 +02:00
Add tracks — auto-updating note blocks with scheduled and event-driven triggers
Track blocks are YAML-fenced sections embedded in markdown notes whose output is rewritten by a background agent. Three trigger types: manual (Run button or Copilot), scheduled (cron / window / once with a 2 min grace window), and event-driven (Gmail/Calendar sync events routed via an LLM classifier with a second-pass agent decision). Output lives between <!--track-target:ID--> comment markers that render as editable content in the Tiptap editor so users can read and extend AI-generated content inline. Core: - Schedule and event pipelines run as independent polling loops (15s / 5s), both calling the same triggerTrackUpdate orchestrator. Events are FIFO via monotonic IDs; a per-track Set guards against duplicate runs. - Track-run agent builds three message variants (manual/timed/event) — the event variant includes a Pass 2 directive to skip updates on false positives flagged by the liberal Pass 1 router. - IPC surface: track:run/get/update/replaceYaml/delete plus tracks:events forward of the pub-sub bus to the renderer. - Gmail emits per-thread events; Calendar bundles a digest per sync. Copilot: - New `tracks` skill (auto-generated canonical schema from Zod via z.toJSONSchema) teaches block creation, editing, and proactive suggestion. - `run-track-block` tool with optional `context` parameter for backfills (e.g. seeding a new email-tracking block from existing synced emails). Renderer: - Tiptap chip (display-only) opens a rich modal with tabs, toggle, schedule details, raw YAML editor, and confirm-to-delete. All mutations go through IPC so the backend stays the single writer. - Target regions use two atom marker nodes (open/close) around real editable content — custom blocks render natively, users can add their own notes. - "Edit with Copilot" seeds a chat session with the note attached. Docs: apps/x/TRACKS.md covers product flows, technical pipeline, and a catalog of every LLM prompt involved with file+line pointers.
This commit is contained in:
parent
ab0147d475
commit
e2c13f0f6f
33 changed files with 3405 additions and 2 deletions
|
|
@ -44,6 +44,14 @@ import { getBillingInfo } from '@x/core/dist/billing/billing.js';
|
|||
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 { triggerTrackUpdate } from '@x/core/dist/knowledge/track/runner.js';
|
||||
import { trackBus } from '@x/core/dist/knowledge/track/bus.js';
|
||||
import {
|
||||
fetchYaml,
|
||||
updateTrackBlock,
|
||||
replaceTrackBlockYaml,
|
||||
deleteTrackBlock,
|
||||
} from '@x/core/dist/knowledge/track/fileops.js';
|
||||
|
||||
/**
|
||||
* Convert markdown to a styled HTML document for PDF/DOCX export.
|
||||
|
|
@ -362,6 +370,19 @@ export async function startServicesWatcher(): Promise<void> {
|
|||
});
|
||||
}
|
||||
|
||||
let tracksWatcher: (() => void) | null = null;
|
||||
export function startTracksWatcher(): void {
|
||||
if (tracksWatcher) return;
|
||||
tracksWatcher = trackBus.subscribe((event) => {
|
||||
const windows = BrowserWindow.getAllWindows();
|
||||
for (const win of windows) {
|
||||
if (!win.isDestroyed() && win.webContents) {
|
||||
win.webContents.send('tracks:events', event);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function stopRunsWatcher(): void {
|
||||
if (runsWatcher) {
|
||||
runsWatcher();
|
||||
|
|
@ -758,6 +779,48 @@ export function setupIpcHandlers() {
|
|||
'voice:synthesize': async (_event, args) => {
|
||||
return voice.synthesizeSpeech(args.text);
|
||||
},
|
||||
// Track handlers
|
||||
'track:run': async (_event, args) => {
|
||||
const result = await triggerTrackUpdate(args.trackId, args.filePath);
|
||||
return { success: !result.error, summary: result.summary ?? undefined, error: result.error };
|
||||
},
|
||||
'track:get': async (_event, args) => {
|
||||
try {
|
||||
const yaml = await fetchYaml(args.filePath, args.trackId);
|
||||
if (yaml === null) return { success: false, error: 'Track not found' };
|
||||
return { success: true, yaml };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
},
|
||||
'track:update': async (_event, args) => {
|
||||
try {
|
||||
await updateTrackBlock(args.filePath, args.trackId, args.updates as Record<string, unknown>);
|
||||
const yaml = await fetchYaml(args.filePath, args.trackId);
|
||||
if (yaml === null) return { success: false, error: 'Track vanished after update' };
|
||||
return { success: true, yaml };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
},
|
||||
'track:replaceYaml': async (_event, args) => {
|
||||
try {
|
||||
await replaceTrackBlockYaml(args.filePath, args.trackId, args.yaml);
|
||||
const yaml = await fetchYaml(args.filePath, args.trackId);
|
||||
if (yaml === null) return { success: false, error: 'Track vanished after replace' };
|
||||
return { success: true, yaml };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
},
|
||||
'track:delete': async (_event, args) => {
|
||||
try {
|
||||
await deleteTrackBlock(args.filePath, args.trackId);
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
},
|
||||
// Billing handler
|
||||
'billing:getInfo': async () => {
|
||||
return await getBillingInfo();
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
setupIpcHandlers,
|
||||
startRunsWatcher,
|
||||
startServicesWatcher,
|
||||
startTracksWatcher,
|
||||
startWorkspaceWatcher,
|
||||
stopRunsWatcher,
|
||||
stopServicesWatcher,
|
||||
|
|
@ -22,6 +23,9 @@ 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 { init as initAgentNotes } from "@x/core/dist/knowledge/agent_notes.js";
|
||||
import { init as initTrackScheduler } from "@x/core/dist/knowledge/track/scheduler.js";
|
||||
import { init as initTrackEventProcessor } from "@x/core/dist/knowledge/track/events.js";
|
||||
|
||||
import { initConfigs } from "@x/core/dist/config/initConfigs.js";
|
||||
import started from "electron-squirrel-startup";
|
||||
import { execSync, exec, execFileSync } from "node:child_process";
|
||||
|
|
@ -228,6 +232,15 @@ app.whenReady().then(async () => {
|
|||
// start services watcher
|
||||
startServicesWatcher();
|
||||
|
||||
// start tracks watcher
|
||||
startTracksWatcher();
|
||||
|
||||
// start track scheduler (cron/window/once)
|
||||
initTrackScheduler();
|
||||
|
||||
// start track event processor (consumes events/pending/, triggers matching tracks)
|
||||
initTrackEventProcessor();
|
||||
|
||||
// start gmail sync
|
||||
initGmailSync();
|
||||
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@
|
|||
"tiptap-markdown": "^0.9.0",
|
||||
"tokenlens": "^1.3.1",
|
||||
"use-stick-to-bottom": "^1.1.1",
|
||||
"yaml": "^2.8.2",
|
||||
"zod": "^4.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-lin
|
|||
import { splitFrontmatter, joinFrontmatter } from '@/lib/frontmatter'
|
||||
import { OnboardingModal } from '@/components/onboarding'
|
||||
import { CommandPalette, type CommandPaletteContext, type CommandPaletteMention } from '@/components/search-dialog'
|
||||
import { TrackModal } from '@/components/track-modal'
|
||||
import { BackgroundTaskDetail } from '@/components/background-task-detail'
|
||||
import { VersionHistoryPanel } from '@/components/version-history-panel'
|
||||
import { FileCardProvider } from '@/contexts/file-card-context'
|
||||
|
|
@ -2687,6 +2688,27 @@ function App() {
|
|||
setPendingPaletteSubmit(null)
|
||||
}, [pendingPaletteSubmit])
|
||||
|
||||
// Listener for track-block "Edit with Copilot" events
|
||||
// (dispatched by apps/renderer/src/extensions/track-block.tsx)
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const ev = e as CustomEvent<{
|
||||
trackId?: string
|
||||
filePath?: string
|
||||
}>
|
||||
const trackId = ev.detail?.trackId
|
||||
const filePath = ev.detail?.filePath
|
||||
if (!trackId || !filePath) return
|
||||
const displayName = filePath.split('/').pop() ?? filePath
|
||||
submitFromPalette(
|
||||
`Let's work on the \`${trackId}\` track in this note. Please load the \`tracks\` skill first, then ask me what I want to change.`,
|
||||
{ path: filePath, displayName },
|
||||
)
|
||||
}
|
||||
window.addEventListener('rowboat:open-copilot-edit-track', handler as EventListener)
|
||||
return () => window.removeEventListener('rowboat:open-copilot-edit-track', handler as EventListener)
|
||||
}, [submitFromPalette])
|
||||
|
||||
const toggleKnowledgePane = useCallback(() => {
|
||||
setIsRightPaneMaximized(false)
|
||||
setIsChatSidebarOpen(prev => !prev)
|
||||
|
|
@ -4560,6 +4582,7 @@ function App() {
|
|||
/>
|
||||
</SidebarSectionProvider>
|
||||
<Toaster />
|
||||
<TrackModal />
|
||||
<OnboardingModal
|
||||
open={showOnboarding}
|
||||
onComplete={handleOnboardingComplete}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ 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 { TrackBlockExtension } from '@/extensions/track-block'
|
||||
import { TrackTargetOpenExtension, TrackTargetCloseExtension } from '@/extensions/track-target'
|
||||
import { ImageBlockExtension } from '@/extensions/image-block'
|
||||
import { EmbedBlockExtension } from '@/extensions/embed-block'
|
||||
import { ChartBlockExtension } from '@/extensions/chart-block'
|
||||
|
|
@ -42,6 +44,31 @@ function preprocessMarkdown(markdown: string): string {
|
|||
})
|
||||
}
|
||||
|
||||
// Convert track-target open/close HTML comment markers into placeholder divs
|
||||
// that TrackTargetOpenExtension / TrackTargetCloseExtension pick up as atom
|
||||
// nodes. Content *between* the markers is left untouched — tiptap-markdown
|
||||
// parses it naturally as whatever it is (paragraphs, lists, custom-block
|
||||
// fences, etc.), all rendered live by the existing extension set.
|
||||
//
|
||||
// CommonMark rule: a type-6 HTML block (div, etc.) runs from the opening tag
|
||||
// line until a blank line terminates it, and markdown inline rules (bold,
|
||||
// italics, links) don't apply inside the block. Without surrounding blank
|
||||
// lines, the line right after our placeholder div gets absorbed as HTML and
|
||||
// its markdown is not parsed. We consume any adjacent newlines in the match
|
||||
// and emit exactly `\n\n<div></div>\n\n` so the HTML block starts and ends on
|
||||
// its own line.
|
||||
function preprocessTrackTargets(md: string): string {
|
||||
return md
|
||||
.replace(
|
||||
/\n?<!--track-target:([^\s>]+)-->\n?/g,
|
||||
(_m, id: string) => `\n\n<div data-type="track-target-open" data-track-id="${id}"></div>\n\n`,
|
||||
)
|
||||
.replace(
|
||||
/\n?<!--\/track-target:([^\s>]+)-->\n?/g,
|
||||
(_m, id: string) => `\n\n<div data-type="track-target-close" data-track-id="${id}"></div>\n\n`,
|
||||
)
|
||||
}
|
||||
|
||||
// Post-process to clean up any zero-width spaces in the output
|
||||
function postprocessMarkdown(markdown: string): string {
|
||||
// Remove lines that contain only the zero-width space marker
|
||||
|
|
@ -140,6 +167,12 @@ function blockToMarkdown(node: JsonNode): string {
|
|||
return serializeList(node, 0).join('\n')
|
||||
case 'taskBlock':
|
||||
return '```task\n' + (node.attrs?.data as string || '{}') + '\n```'
|
||||
case 'trackBlock':
|
||||
return '```track\n' + (node.attrs?.data as string || '') + '\n```'
|
||||
case 'trackTargetOpen':
|
||||
return `<!--track-target:${(node.attrs?.trackId as string) ?? ''}-->`
|
||||
case 'trackTargetClose':
|
||||
return `<!--/track-target:${(node.attrs?.trackId as string) ?? ''}-->`
|
||||
case 'imageBlock':
|
||||
return '```image\n' + (node.attrs?.data as string || '{}') + '\n```'
|
||||
case 'embedBlock':
|
||||
|
|
@ -638,6 +671,9 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
|
|||
}),
|
||||
ImageUploadPlaceholderExtension,
|
||||
TaskBlockExtension,
|
||||
TrackBlockExtension.configure({ notePath }),
|
||||
TrackTargetOpenExtension,
|
||||
TrackTargetCloseExtension,
|
||||
ImageBlockExtension,
|
||||
EmbedBlockExtension,
|
||||
ChartBlockExtension,
|
||||
|
|
@ -1032,8 +1068,9 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
|
|||
const normalizeForCompare = (s: string) => s.split('\n').map(line => line.trimEnd()).join('\n').trim()
|
||||
if (normalizeForCompare(currentContent) !== normalizeForCompare(content)) {
|
||||
isInternalUpdate.current = true
|
||||
// Pre-process to preserve blank lines
|
||||
const preprocessed = preprocessMarkdown(content)
|
||||
// Pre-process to preserve blank lines, then wrap track-target comment
|
||||
// regions into placeholder divs so TrackTargetExtension can pick them up.
|
||||
const preprocessed = preprocessMarkdown(preprocessTrackTargets(content))
|
||||
// Treat tab-open content as baseline: do not add hydration to undo history.
|
||||
editor.chain().setMeta('addToHistory', false).setContent(preprocessed).run()
|
||||
isInternalUpdate.current = false
|
||||
|
|
|
|||
522
apps/x/apps/renderer/src/components/track-modal.tsx
Normal file
522
apps/x/apps/renderer/src/components/track-modal.tsx
Normal file
|
|
@ -0,0 +1,522 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { z } from 'zod'
|
||||
import '@/styles/track-modal.css'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Radio, Clock, Play, Loader2, Sparkles, Code2, CalendarClock, Zap,
|
||||
Trash2, ChevronDown, ChevronUp,
|
||||
} from 'lucide-react'
|
||||
import { parse as parseYaml } from 'yaml'
|
||||
import { Streamdown } from 'streamdown'
|
||||
import { TrackBlockSchema, type TrackSchedule } from '@x/shared/dist/track-block.js'
|
||||
import { useTrackStatus } from '@/hooks/use-track-status'
|
||||
import type { OpenTrackModalDetail } from '@/extensions/track-block'
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schedule helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CRON_PHRASES: Record<string, string> = {
|
||||
'* * * * *': 'Every minute',
|
||||
'*/5 * * * *': 'Every 5 minutes',
|
||||
'*/15 * * * *': 'Every 15 minutes',
|
||||
'*/30 * * * *': 'Every 30 minutes',
|
||||
'0 * * * *': 'Hourly',
|
||||
'0 */2 * * *': 'Every 2 hours',
|
||||
'0 */6 * * *': 'Every 6 hours',
|
||||
'0 */12 * * *': 'Every 12 hours',
|
||||
'0 0 * * *': 'Daily at midnight',
|
||||
'0 8 * * *': 'Daily at 8 AM',
|
||||
'0 9 * * *': 'Daily at 9 AM',
|
||||
'0 12 * * *': 'Daily at noon',
|
||||
'0 18 * * *': 'Daily at 6 PM',
|
||||
'0 9 * * 1-5': 'Weekdays at 9 AM',
|
||||
'0 17 * * 1-5': 'Weekdays at 5 PM',
|
||||
'0 0 * * 0': 'Sundays at midnight',
|
||||
'0 0 * * 1': 'Mondays at midnight',
|
||||
'0 0 1 * *': 'First of each month',
|
||||
}
|
||||
|
||||
function describeCron(expr: string): string {
|
||||
return CRON_PHRASES[expr.trim()] ?? expr
|
||||
}
|
||||
|
||||
type ScheduleIconKind = 'timer' | 'calendar' | 'target' | 'bolt'
|
||||
type ScheduleSummary = { icon: ScheduleIconKind; text: string }
|
||||
|
||||
function summarizeSchedule(schedule?: TrackSchedule): ScheduleSummary {
|
||||
if (!schedule) return { icon: 'bolt', text: 'Manual only' }
|
||||
if (schedule.type === 'once') {
|
||||
return { icon: 'target', text: `Once at ${formatDateTime(schedule.runAt)}` }
|
||||
}
|
||||
if (schedule.type === 'cron') {
|
||||
return { icon: 'timer', text: describeCron(schedule.expression) }
|
||||
}
|
||||
if (schedule.type === 'window') {
|
||||
return { icon: 'calendar', text: `${describeCron(schedule.cron)} · ${schedule.startTime}–${schedule.endTime}` }
|
||||
}
|
||||
return { icon: 'calendar', text: 'Scheduled' }
|
||||
}
|
||||
|
||||
function ScheduleIcon({ icon, size = 14 }: { icon: ScheduleIconKind; size?: number }) {
|
||||
if (icon === 'timer') return <Clock size={size} />
|
||||
if (icon === 'calendar' || icon === 'target') return <CalendarClock size={size} />
|
||||
return <Zap size={size} />
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Modal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type Tab = 'what' | 'when' | 'event' | 'details'
|
||||
|
||||
export function TrackModal() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [detail, setDetail] = useState<OpenTrackModalDetail | null>(null)
|
||||
const [yaml, setYaml] = useState<string>('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState<Tab>('what')
|
||||
const [editingRaw, setEditingRaw] = useState(false)
|
||||
const [rawDraft, setRawDraft] = useState('')
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
// Listen for the open event and seed modal state.
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const ev = e as CustomEvent<OpenTrackModalDetail>
|
||||
const d = ev.detail
|
||||
if (!d?.trackId || !d?.filePath) return
|
||||
setDetail(d)
|
||||
setYaml(d.initialYaml ?? '')
|
||||
setActiveTab('what')
|
||||
setEditingRaw(false)
|
||||
setRawDraft('')
|
||||
setShowAdvanced(false)
|
||||
setConfirmingDelete(false)
|
||||
setError(null)
|
||||
setOpen(true)
|
||||
void fetchFresh(d)
|
||||
}
|
||||
window.addEventListener('rowboat:open-track-modal', handler as EventListener)
|
||||
return () => window.removeEventListener('rowboat:open-track-modal', handler as EventListener)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const fetchFresh = useCallback(async (d: OpenTrackModalDetail) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const res = await window.ipc.invoke('track:get', { trackId: d.trackId, filePath: stripKnowledgePrefix(d.filePath) })
|
||||
if (res?.success && res.yaml) {
|
||||
setYaml(res.yaml)
|
||||
} else if (res?.error) {
|
||||
setError(res.error)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const track = useMemo<z.infer<typeof TrackBlockSchema> | null>(() => {
|
||||
if (!yaml) return null
|
||||
try { return TrackBlockSchema.parse(parseYaml(yaml)) } catch { return null }
|
||||
}, [yaml])
|
||||
|
||||
const trackId = track?.trackId ?? detail?.trackId ?? ''
|
||||
const instruction = track?.instruction ?? ''
|
||||
const active = track?.active ?? true
|
||||
const schedule = track?.schedule
|
||||
const eventMatchCriteria = track?.eventMatchCriteria ?? ''
|
||||
const lastRunAt = track?.lastRunAt ?? ''
|
||||
const lastRunId = track?.lastRunId ?? ''
|
||||
const lastRunSummary = track?.lastRunSummary ?? ''
|
||||
const scheduleSummary = useMemo(() => summarizeSchedule(schedule), [schedule])
|
||||
const triggerType: 'scheduled' | 'event' | 'manual' =
|
||||
schedule ? 'scheduled' : eventMatchCriteria ? 'event' : 'manual'
|
||||
|
||||
const knowledgeRelPath = detail ? stripKnowledgePrefix(detail.filePath) : ''
|
||||
|
||||
const allTrackStatus = useTrackStatus()
|
||||
const runState = allTrackStatus.get(`${trackId}:${knowledgeRelPath}`) ?? { status: 'idle' as const }
|
||||
const isRunning = runState.status === 'running'
|
||||
|
||||
useEffect(() => {
|
||||
if (editingRaw && textareaRef.current) {
|
||||
textareaRef.current.focus()
|
||||
textareaRef.current.setSelectionRange(
|
||||
textareaRef.current.value.length,
|
||||
textareaRef.current.value.length,
|
||||
)
|
||||
}
|
||||
}, [editingRaw])
|
||||
|
||||
const visibleTabs: { key: Tab; label: string; visible: boolean }[] = [
|
||||
{ key: 'what', label: 'What to track', visible: true },
|
||||
{ key: 'when', label: 'When to run', visible: !!schedule },
|
||||
{ key: 'event', label: 'Event matching', visible: !!eventMatchCriteria },
|
||||
{ key: 'details', label: 'Details', visible: true },
|
||||
]
|
||||
const shown = visibleTabs.filter(t => t.visible)
|
||||
|
||||
useEffect(() => {
|
||||
if (!shown.some(t => t.key === activeTab)) setActiveTab('what')
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [schedule, eventMatchCriteria])
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// IPC-backed mutations
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const runUpdate = useCallback(async (updates: Record<string, unknown>) => {
|
||||
if (!detail) return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await window.ipc.invoke('track:update', {
|
||||
trackId: detail.trackId,
|
||||
filePath: stripKnowledgePrefix(detail.filePath),
|
||||
updates,
|
||||
})
|
||||
if (res?.success && res.yaml) {
|
||||
setYaml(res.yaml)
|
||||
} else if (res?.error) {
|
||||
setError(res.error)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [detail])
|
||||
|
||||
const handleToggleActive = useCallback(() => {
|
||||
void runUpdate({ active: !active })
|
||||
}, [active, runUpdate])
|
||||
|
||||
const handleRun = useCallback(async () => {
|
||||
if (!detail || isRunning) return
|
||||
try {
|
||||
await window.ipc.invoke('track:run', {
|
||||
trackId: detail.trackId,
|
||||
filePath: stripKnowledgePrefix(detail.filePath),
|
||||
})
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
}, [detail, isRunning])
|
||||
|
||||
const handleSaveRaw = useCallback(async () => {
|
||||
if (!detail) return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await window.ipc.invoke('track:replaceYaml', {
|
||||
trackId: detail.trackId,
|
||||
filePath: stripKnowledgePrefix(detail.filePath),
|
||||
yaml: rawDraft,
|
||||
})
|
||||
if (res?.success && res.yaml) {
|
||||
setYaml(res.yaml)
|
||||
setEditingRaw(false)
|
||||
} else if (res?.error) {
|
||||
setError(res.error)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [detail, rawDraft])
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!detail) return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await window.ipc.invoke('track:delete', {
|
||||
trackId: detail.trackId,
|
||||
filePath: stripKnowledgePrefix(detail.filePath),
|
||||
})
|
||||
if (res?.success) {
|
||||
// Tell the editor to remove the node so Tiptap's next save doesn't
|
||||
// re-create the track block on disk.
|
||||
try { detail.onDeleted() } catch { /* editor may have unmounted */ }
|
||||
setOpen(false)
|
||||
} else if (res?.error) {
|
||||
setError(res.error)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [detail])
|
||||
|
||||
const handleEditWithCopilot = useCallback(() => {
|
||||
if (!detail) return
|
||||
window.dispatchEvent(new CustomEvent('rowboat:open-copilot-edit-track', {
|
||||
detail: {
|
||||
trackId: detail.trackId,
|
||||
filePath: detail.filePath,
|
||||
},
|
||||
}))
|
||||
setOpen(false)
|
||||
}, [detail])
|
||||
|
||||
if (!detail) return null
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent
|
||||
className="track-modal-content w-[min(44rem,calc(100%-2rem))] max-w-2xl p-0 gap-0 overflow-hidden rounded-xl"
|
||||
data-trigger={triggerType}
|
||||
data-active={active ? 'true' : 'false'}
|
||||
>
|
||||
<div className="track-modal-header">
|
||||
<div className="track-modal-header-left">
|
||||
<div className="track-modal-icon-wrap">
|
||||
<Radio size={16} />
|
||||
</div>
|
||||
<div className="track-modal-title-col">
|
||||
<DialogHeader className="space-y-0">
|
||||
<DialogTitle className="track-modal-title">
|
||||
{trackId || 'Track'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="track-modal-subtitle">
|
||||
<ScheduleIcon icon={scheduleSummary.icon} size={11} />
|
||||
{scheduleSummary.text}
|
||||
{eventMatchCriteria && triggerType === 'scheduled' && (
|
||||
<span className="track-modal-subtitle-sep">· also event-driven</span>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
</div>
|
||||
<div className="track-modal-header-actions">
|
||||
<label className="track-modal-toggle">
|
||||
<Switch checked={active} onCheckedChange={handleToggleActive} disabled={saving} />
|
||||
<span className="track-modal-toggle-label">{active ? 'Active' : 'Paused'}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="track-modal-tabs">
|
||||
{shown.map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
className={`track-modal-tab ${activeTab === tab.key ? 'track-modal-tab-active' : ''}`}
|
||||
onClick={() => { setActiveTab(tab.key); setEditingRaw(false) }}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="track-modal-body">
|
||||
{loading && <div className="track-modal-loading"><Loader2 size={14} className="animate-spin" /> Loading latest…</div>}
|
||||
|
||||
{activeTab === 'what' && (
|
||||
<div className="track-modal-prose">
|
||||
{instruction
|
||||
? <Streamdown className="track-modal-markdown">{instruction}</Streamdown>
|
||||
: <span className="track-modal-empty">No instruction set.</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'when' && schedule && (
|
||||
<div className="track-modal-when">
|
||||
<div className="track-modal-when-headline">
|
||||
<ScheduleIcon icon={scheduleSummary.icon} size={18} />
|
||||
<span>{scheduleSummary.text}</span>
|
||||
</div>
|
||||
<dl className="track-modal-dl">
|
||||
<dt>Type</dt><dd><code>{schedule.type}</code></dd>
|
||||
{schedule.type === 'cron' && (
|
||||
<>
|
||||
<dt>Expression</dt><dd><code>{schedule.expression}</code></dd>
|
||||
</>
|
||||
)}
|
||||
{schedule.type === 'window' && (
|
||||
<>
|
||||
<dt>Expression</dt><dd><code>{schedule.cron}</code></dd>
|
||||
<dt>Window</dt><dd>{schedule.startTime} – {schedule.endTime}</dd>
|
||||
</>
|
||||
)}
|
||||
{schedule.type === 'once' && (
|
||||
<>
|
||||
<dt>Runs at</dt><dd>{formatDateTime(schedule.runAt)}</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'event' && (
|
||||
<div className="track-modal-prose">
|
||||
{eventMatchCriteria
|
||||
? <Streamdown className="track-modal-markdown">{eventMatchCriteria}</Streamdown>
|
||||
: <span className="track-modal-empty">No event matching set.</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'details' && (
|
||||
<div className="track-modal-details">
|
||||
<dl className="track-modal-dl">
|
||||
<dt>Track ID</dt><dd><code>{trackId}</code></dd>
|
||||
<dt>File</dt><dd><code>{detail.filePath}</code></dd>
|
||||
<dt>Status</dt><dd>{active ? 'Active' : 'Paused'}</dd>
|
||||
{lastRunAt && (<>
|
||||
<dt>Last run</dt><dd>{formatDateTime(lastRunAt)}</dd>
|
||||
</>)}
|
||||
{lastRunId && (<>
|
||||
<dt>Run ID</dt><dd><code>{lastRunId}</code></dd>
|
||||
</>)}
|
||||
{lastRunSummary && (<>
|
||||
<dt>Summary</dt><dd>{lastRunSummary}</dd>
|
||||
</>)}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Advanced (raw YAML) — all tabs */}
|
||||
<div className="track-modal-advanced">
|
||||
<button
|
||||
className="track-modal-advanced-toggle"
|
||||
onClick={() => {
|
||||
const next = !showAdvanced
|
||||
setShowAdvanced(next)
|
||||
if (next) {
|
||||
setRawDraft(yaml)
|
||||
setEditingRaw(true)
|
||||
} else {
|
||||
setEditingRaw(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{showAdvanced ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||
<Code2 size={12} />
|
||||
Advanced (raw YAML)
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<div className="track-modal-raw-editor">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={rawDraft}
|
||||
onChange={(e) => setRawDraft(e.target.value)}
|
||||
rows={12}
|
||||
spellCheck={false}
|
||||
className="track-modal-textarea"
|
||||
/>
|
||||
<div className="track-modal-raw-actions">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { setRawDraft(yaml); setShowAdvanced(false); setEditingRaw(false) }}
|
||||
disabled={saving}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSaveRaw}
|
||||
disabled={saving || rawDraft.trim() === yaml.trim()}
|
||||
>
|
||||
{saving ? <Loader2 size={12} className="animate-spin" /> : null}
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Danger zone — on Details tab only */}
|
||||
{activeTab === 'details' && (
|
||||
<div className="track-modal-danger-zone">
|
||||
{confirmingDelete ? (
|
||||
<div className="track-modal-confirm">
|
||||
<span>Delete this track and its generated content?</span>
|
||||
<div className="track-modal-confirm-actions">
|
||||
<Button variant="outline" size="sm" onClick={() => setConfirmingDelete(false)} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={handleDelete} disabled={saving}>
|
||||
{saving ? <Loader2 size={12} className="animate-spin" /> : <Trash2 size={12} />}
|
||||
Yes, delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="track-modal-delete-btn"
|
||||
onClick={() => setConfirmingDelete(true)}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
Delete track block
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="track-modal-error">{error}</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="track-modal-footer">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleEditWithCopilot}
|
||||
disabled={saving}
|
||||
>
|
||||
<Sparkles size={12} />
|
||||
Edit with Copilot
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleRun}
|
||||
disabled={isRunning || saving}
|
||||
className="track-modal-run-btn"
|
||||
>
|
||||
{isRunning ? <Loader2 size={12} className="animate-spin" /> : <Play size={12} />}
|
||||
{isRunning ? 'Running…' : 'Run now'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function stripKnowledgePrefix(p: string): string {
|
||||
return p.replace(/^knowledge\//, '')
|
||||
}
|
||||
178
apps/x/apps/renderer/src/extensions/track-block.tsx
Normal file
178
apps/x/apps/renderer/src/extensions/track-block.tsx
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
import { z } from 'zod'
|
||||
import { useMemo } from 'react'
|
||||
import { mergeAttributes, Node } from '@tiptap/react'
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
|
||||
import { Radio, Loader2 } from 'lucide-react'
|
||||
import { parse as parseYaml } from 'yaml'
|
||||
import { TrackBlockSchema } from '@x/shared/dist/track-block.js'
|
||||
import { useTrackStatus } from '@/hooks/use-track-status'
|
||||
|
||||
function truncate(text: string, maxLen: number): string {
|
||||
const clean = text.replace(/\s+/g, ' ').trim()
|
||||
if (clean.length <= maxLen) return clean
|
||||
return clean.slice(0, maxLen).trimEnd() + '…'
|
||||
}
|
||||
|
||||
// Detail shape for the open-track-modal window event. Defined here so the
|
||||
// consumer (TrackModal) can import it without a circular dependency.
|
||||
export type OpenTrackModalDetail = {
|
||||
trackId: string
|
||||
/** Workspace-relative path, e.g. "knowledge/Notes/foo.md" */
|
||||
filePath: string
|
||||
/** Best-effort initial YAML from Tiptap's cached node attr (modal refetches fresh). */
|
||||
initialYaml: string
|
||||
/** Invoked after a successful IPC delete so the editor can remove the node. */
|
||||
onDeleted: () => void
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chip (display-only)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TrackBlockView({ node, deleteNode, extension }: {
|
||||
node: { attrs: Record<string, unknown> }
|
||||
deleteNode: () => void
|
||||
updateAttributes: (attrs: Record<string, unknown>) => void
|
||||
extension: { options: { notePath?: string } }
|
||||
}) {
|
||||
const raw = node.attrs.data as string
|
||||
|
||||
const track = useMemo<z.infer<typeof TrackBlockSchema> | null>(() => {
|
||||
try {
|
||||
return TrackBlockSchema.parse(parseYaml(raw))
|
||||
} catch { return null }
|
||||
}, [raw]) as z.infer<typeof TrackBlockSchema> | null;
|
||||
|
||||
const trackId = track?.trackId ?? ''
|
||||
const instruction = track?.instruction ?? ''
|
||||
const active = track?.active ?? true
|
||||
const schedule = track?.schedule
|
||||
const eventMatchCriteria = track?.eventMatchCriteria ?? ''
|
||||
const notePath = extension.options.notePath
|
||||
const trackFilePath = notePath?.replace(/^knowledge\//, '') ?? ''
|
||||
|
||||
const triggerType: 'scheduled' | 'event' | 'manual' =
|
||||
schedule ? 'scheduled' : eventMatchCriteria ? 'event' : 'manual'
|
||||
|
||||
const allTrackStatus = useTrackStatus()
|
||||
const runState = allTrackStatus.get(`${track?.trackId}:${trackFilePath}`) ?? { status: 'idle' as const }
|
||||
const isRunning = runState.status === 'running'
|
||||
|
||||
const handleOpen = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (!trackId || !notePath) return
|
||||
const detail: OpenTrackModalDetail = {
|
||||
trackId,
|
||||
filePath: notePath,
|
||||
initialYaml: raw,
|
||||
onDeleted: () => deleteNode(),
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent<OpenTrackModalDetail>(
|
||||
'rowboat:open-track-modal',
|
||||
{ detail },
|
||||
))
|
||||
}
|
||||
|
||||
const handleKey = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
handleOpen(e as unknown as React.MouseEvent)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper
|
||||
className="track-block-chip-wrapper"
|
||||
data-type="track-block"
|
||||
data-trigger={triggerType}
|
||||
data-active={active ? 'true' : 'false'}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`track-block-chip ${!active ? 'track-block-chip-paused-state' : ''} ${isRunning ? 'track-block-chip-running' : ''}`}
|
||||
onClick={handleOpen}
|
||||
onKeyDown={handleKey}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
title={instruction ? `${trackId}: ${instruction}` : trackId}
|
||||
>
|
||||
{isRunning
|
||||
? <Loader2 size={13} className="animate-spin track-block-chip-icon" />
|
||||
: <Radio size={13} className="track-block-chip-icon" />}
|
||||
<span className="track-block-chip-id">{trackId || 'track'}</span>
|
||||
{instruction && (
|
||||
<span className="track-block-chip-sep">·</span>
|
||||
)}
|
||||
{instruction && (
|
||||
<span className="track-block-chip-instruction">{truncate(instruction, 80)}</span>
|
||||
)}
|
||||
{!active && <span className="track-block-chip-paused-label">paused</span>}
|
||||
</button>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tiptap extension — unchanged schema, parseHTML, serialize
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const TrackBlockExtension = Node.create({
|
||||
name: 'trackBlock',
|
||||
group: 'block',
|
||||
atom: true,
|
||||
selectable: true,
|
||||
draggable: false,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
notePath: undefined as string | undefined,
|
||||
}
|
||||
},
|
||||
|
||||
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-track')) {
|
||||
return { data: code.textContent || '' }
|
||||
}
|
||||
return false
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
|
||||
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'track-block' })]
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(TrackBlockView)
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
markdown: {
|
||||
serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) {
|
||||
state.write('```track\n' + node.attrs.data + '\n```')
|
||||
state.closeBlock(node)
|
||||
},
|
||||
parse: {
|
||||
// handled by parseHTML
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
90
apps/x/apps/renderer/src/extensions/track-target.tsx
Normal file
90
apps/x/apps/renderer/src/extensions/track-target.tsx
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { mergeAttributes, Node } from '@tiptap/react'
|
||||
|
||||
/**
|
||||
* Track target markers — two Tiptap atom nodes that represent the open and
|
||||
* close HTML comment markers bracketing a track's output region on disk:
|
||||
*
|
||||
* <!--track-target:ID--> → TrackTargetOpenExtension
|
||||
* content in between → regular Tiptap nodes (paragraphs, lists,
|
||||
* custom blocks, whatever tiptap-markdown parses)
|
||||
* <!--/track-target:ID--> → TrackTargetCloseExtension
|
||||
*
|
||||
* The markers are *semantic boundaries*, not a UI container. Content between
|
||||
* them is real, editable document content — fully rendered by the existing
|
||||
* extension set and freely editable by the user. The backend's updateContent()
|
||||
* in fileops.ts still locates the region on disk by these comment markers.
|
||||
*
|
||||
* Load path: `markdown-editor.tsx#preprocessTrackTargets` does a per-marker
|
||||
* regex replace, converting each comment into a placeholder div that these
|
||||
* extensions' parseHTML rules pick up. No content capture.
|
||||
*
|
||||
* Save path: both Tiptap's built-in markdown serializer
|
||||
* (`addStorage().markdown.serialize`) AND the app's custom serializer
|
||||
* (`blockToMarkdown` in markdown-editor.tsx) write the original comment form
|
||||
* back out — they must stay in sync.
|
||||
*/
|
||||
|
||||
type MarkerVariant = 'open' | 'close'
|
||||
|
||||
function buildMarkerExtension(variant: MarkerVariant) {
|
||||
const name = variant === 'open' ? 'trackTargetOpen' : 'trackTargetClose'
|
||||
const htmlType = variant === 'open' ? 'track-target-open' : 'track-target-close'
|
||||
const commentFor = (id: string) =>
|
||||
variant === 'open' ? `<!--track-target:${id}-->` : `<!--/track-target:${id}-->`
|
||||
|
||||
return Node.create({
|
||||
name,
|
||||
group: 'block',
|
||||
atom: true,
|
||||
selectable: true,
|
||||
draggable: false,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
trackId: { default: '' },
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: `div[data-type="${htmlType}"]`,
|
||||
getAttrs(el) {
|
||||
if (!(el instanceof HTMLElement)) return false
|
||||
return { trackId: el.getAttribute('data-track-id') ?? '' }
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes, node }: { HTMLAttributes: Record<string, unknown>; node: { attrs: Record<string, unknown> } }) {
|
||||
return [
|
||||
'div',
|
||||
mergeAttributes(HTMLAttributes, {
|
||||
'data-type': htmlType,
|
||||
'data-track-id': (node.attrs.trackId as string) ?? '',
|
||||
}),
|
||||
]
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
markdown: {
|
||||
serialize(
|
||||
state: { write: (text: string) => void; closeBlock: (node: unknown) => void },
|
||||
node: { attrs: { trackId: string } },
|
||||
) {
|
||||
state.write(commentFor(node.attrs.trackId ?? ''))
|
||||
state.closeBlock(node)
|
||||
},
|
||||
parse: {
|
||||
// handled via preprocessTrackTargets → parseHTML
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const TrackTargetOpenExtension = buildMarkerExtension('open')
|
||||
export const TrackTargetCloseExtension = buildMarkerExtension('close')
|
||||
72
apps/x/apps/renderer/src/hooks/use-track-status.ts
Normal file
72
apps/x/apps/renderer/src/hooks/use-track-status.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import z from 'zod';
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import { TrackEvent } from '@x/shared/dist/track-block.js';
|
||||
|
||||
export type TrackRunStatus = 'idle' | 'running' | 'done' | 'error';
|
||||
|
||||
export interface TrackState {
|
||||
status: TrackRunStatus;
|
||||
runId?: string;
|
||||
summary?: string | null;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
// Module-level store — shared across all hook consumers, subscribed once
|
||||
// We replace the Map on every mutation so useSyncExternalStore detects the change
|
||||
let store = new Map<string, TrackState>();
|
||||
const listeners = new Set<() => void>();
|
||||
let subscribed = false;
|
||||
|
||||
function updateStore(fn: (prev: Map<string, TrackState>) => void) {
|
||||
store = new Map(store);
|
||||
fn(store);
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
function ensureSubscription() {
|
||||
if (subscribed) return;
|
||||
subscribed = true;
|
||||
window.ipc.on('tracks:events', ((event: z.infer<typeof TrackEvent>) => {
|
||||
const key = `${event.trackId}:${event.filePath}`;
|
||||
|
||||
if (event.type === 'track_run_start') {
|
||||
updateStore(s => s.set(key, { status: 'running', runId: event.runId }));
|
||||
} else if (event.type === 'track_run_complete') {
|
||||
updateStore(s => s.set(key, {
|
||||
status: event.error ? 'error' : 'done',
|
||||
runId: event.runId,
|
||||
summary: event.summary ?? null,
|
||||
error: event.error ?? null,
|
||||
}));
|
||||
// Auto-clear after 5 seconds
|
||||
setTimeout(() => {
|
||||
updateStore(s => s.delete(key));
|
||||
}, 5000);
|
||||
}
|
||||
}) as (event: z.infer<typeof TrackEvent>) => void);
|
||||
}
|
||||
|
||||
function subscribe(onStoreChange: () => void): () => void {
|
||||
ensureSubscription();
|
||||
listeners.add(onStoreChange);
|
||||
return () => { listeners.delete(onStoreChange); };
|
||||
}
|
||||
|
||||
function getSnapshot(): Map<string, TrackState> {
|
||||
return store;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a Map of all track run states, keyed by "trackId:filePath".
|
||||
*
|
||||
* Usage in a track block component:
|
||||
* const trackStatus = useTrackStatus();
|
||||
* const state = trackStatus.get(`${trackId}:${filePath}`) ?? { status: 'idle' };
|
||||
*
|
||||
* Usage for a global indicator:
|
||||
* const trackStatus = useTrackStatus();
|
||||
* const anyRunning = [...trackStatus.values()].some(s => s.status === 'running');
|
||||
*/
|
||||
export function useTrackStatus(): Map<string, TrackState> {
|
||||
return useSyncExternalStore(subscribe, getSnapshot);
|
||||
}
|
||||
|
|
@ -611,6 +611,155 @@
|
|||
.tiptap-editor .ProseMirror .task-block-last-run {
|
||||
color: color-mix(in srgb, var(--foreground) 38%, transparent);
|
||||
}
|
||||
/* =============================================================
|
||||
Track Block — inline chip (display-only)
|
||||
The chip just opens a modal (TrackModal). All mutations live in the
|
||||
modal and go through IPC, so the editor never writes track state.
|
||||
============================================================= */
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-chip-wrapper {
|
||||
--track-accent: #64748b; /* default: manual/slate */
|
||||
margin: 4px 0;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-trigger="scheduled"] { --track-accent: #6366f1; }
|
||||
.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-trigger="event"] { --track-accent: #a855f7; }
|
||||
.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-trigger="manual"] { --track-accent: #64748b; }
|
||||
.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-active="false"] { --track-accent: color-mix(in srgb, var(--foreground) 30%, transparent); }
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
max-width: 100%;
|
||||
padding: 6px 12px;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
line-height: 1.3;
|
||||
color: var(--foreground);
|
||||
background: color-mix(in srgb, var(--track-accent) 8%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--track-accent) 35%, transparent);
|
||||
border-left: 3px solid var(--track-accent);
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.12s ease, box-shadow 0.12s ease, transform 0.06s ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-chip:hover {
|
||||
background: color-mix(in srgb, var(--track-accent) 14%, transparent);
|
||||
box-shadow: 0 1px 4px color-mix(in srgb, var(--track-accent) 20%, transparent);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-chip:active {
|
||||
transform: translateY(0.5px);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-chip:focus-visible {
|
||||
outline: 2px solid var(--track-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-chip-paused-state {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-chip-running {
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--track-accent) 40%, transparent);
|
||||
animation: track-chip-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes track-chip-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--track-accent) 35%, transparent); }
|
||||
50% { box-shadow: 0 0 0 4px color-mix(in srgb, var(--track-accent) 15%, transparent); }
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-chip-icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--track-accent);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-chip-id {
|
||||
font-weight: 600;
|
||||
color: var(--track-accent);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-chip-sep {
|
||||
color: color-mix(in srgb, var(--foreground) 25%, transparent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-chip-instruction {
|
||||
color: color-mix(in srgb, var(--foreground) 80%, transparent);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-chip-paused-label {
|
||||
flex-shrink: 0;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||
background: color-mix(in srgb, var(--foreground) 10%, transparent);
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-chip-wrapper.ProseMirror-selectednode .track-block-chip {
|
||||
outline: 2px solid var(--track-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* =============================================================
|
||||
Track target markers — thin visual bookends around a track's
|
||||
output region. The content BETWEEN these markers is normal,
|
||||
editable document content (rendered by the existing extensions).
|
||||
============================================================= */
|
||||
|
||||
.tiptap-editor .ProseMirror div[data-type="track-target-open"] {
|
||||
position: relative;
|
||||
height: 1px;
|
||||
margin: 14px 0 6px 0;
|
||||
background: color-mix(in srgb, var(--foreground) 15%, transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror div[data-type="track-target-open"]::before {
|
||||
content: 'track: ' attr(data-track-id);
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
left: 8px;
|
||||
padding: 0 6px;
|
||||
background: var(--background, #fff);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
color: color-mix(in srgb, var(--foreground) 50%, transparent);
|
||||
text-transform: none;
|
||||
white-space: nowrap;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror div[data-type="track-target-close"] {
|
||||
height: 1px;
|
||||
margin: 6px 0 14px 0;
|
||||
background: color-mix(in srgb, var(--foreground) 10%, transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror div[data-type="track-target-open"].ProseMirror-selectednode,
|
||||
.tiptap-editor .ProseMirror div[data-type="track-target-close"].ProseMirror-selectednode {
|
||||
outline: 2px solid color-mix(in srgb, var(--foreground) 30%, transparent);
|
||||
outline-offset: 1px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Shared block styles (image, embed, chart, table) */
|
||||
.tiptap-editor .ProseMirror .image-block-wrapper,
|
||||
|
|
|
|||
311
apps/x/apps/renderer/src/styles/track-modal.css
Normal file
311
apps/x/apps/renderer/src/styles/track-modal.css
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
/* =============================================================
|
||||
Track Modal — dialog overlay for track block details / edits
|
||||
============================================================= */
|
||||
|
||||
.track-modal-content {
|
||||
--track-accent: #64748b;
|
||||
}
|
||||
|
||||
.track-modal-content[data-trigger="scheduled"] { --track-accent: #6366f1; }
|
||||
.track-modal-content[data-trigger="event"] { --track-accent: #a855f7; }
|
||||
.track-modal-content[data-trigger="manual"] { --track-accent: #64748b; }
|
||||
.track-modal-content[data-active="false"] { --track-accent: color-mix(in srgb, var(--foreground) 30%, transparent); }
|
||||
|
||||
/* Header */
|
||||
.track-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: color-mix(in srgb, var(--track-accent) 6%, transparent);
|
||||
border-left: 4px solid var(--track-accent);
|
||||
}
|
||||
|
||||
.track-modal-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.track-modal-icon-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--track-accent) 15%, transparent);
|
||||
color: var(--track-accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.track-modal-title-col {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.track-modal-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
}
|
||||
|
||||
.track-modal-subtitle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: color-mix(in srgb, var(--foreground) 60%, transparent);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.track-modal-subtitle-sep {
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.track-modal-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.track-modal-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.track-modal-toggle-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: color-mix(in srgb, var(--foreground) 70%, transparent);
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.track-modal-tabs {
|
||||
display: flex;
|
||||
padding: 0 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.track-modal-tab {
|
||||
padding: 10px 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: color 0.1s ease, border-color 0.1s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.track-modal-tab:hover {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.track-modal-tab-active {
|
||||
color: var(--track-accent);
|
||||
border-bottom-color: var(--track-accent);
|
||||
}
|
||||
|
||||
/* Body */
|
||||
.track-modal-body {
|
||||
padding: 18px 20px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.track-modal-loading {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: color-mix(in srgb, var(--foreground) 50%, transparent);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.track-modal-prose {
|
||||
font-size: 13.5px;
|
||||
line-height: 1.6;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.track-modal-markdown {
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.track-modal-markdown > *:first-child { margin-top: 0; }
|
||||
.track-modal-markdown > *:last-child { margin-bottom: 0; }
|
||||
|
||||
.track-modal-empty {
|
||||
color: color-mix(in srgb, var(--foreground) 40%, transparent);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* When-to-run panel */
|
||||
.track-modal-when {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.track-modal-when-headline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--track-accent);
|
||||
padding: 12px 14px;
|
||||
background: color-mix(in srgb, var(--track-accent) 10%, transparent);
|
||||
border-radius: 8px;
|
||||
border-left: 3px solid var(--track-accent);
|
||||
}
|
||||
|
||||
/* Description list (Details / When) */
|
||||
.track-modal-dl {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
column-gap: 16px;
|
||||
row-gap: 8px;
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.track-modal-dl dt {
|
||||
color: color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.track-modal-dl dd {
|
||||
margin: 0;
|
||||
color: var(--foreground);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.track-modal-dl code {
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 12px;
|
||||
padding: 1px 6px;
|
||||
background: color-mix(in srgb, var(--foreground) 6%, transparent);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Advanced / raw YAML disclosure */
|
||||
.track-modal-advanced {
|
||||
margin-top: 20px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px dashed color-mix(in srgb, var(--foreground) 12%, transparent);
|
||||
}
|
||||
|
||||
.track-modal-advanced-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 0;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.track-modal-advanced-toggle:hover {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.track-modal-raw-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.track-modal-textarea {
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.track-modal-raw-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Danger zone */
|
||||
.track-modal-danger-zone {
|
||||
margin-top: 20px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px dashed color-mix(in srgb, var(--destructive, #ef4444) 20%, transparent);
|
||||
}
|
||||
|
||||
.track-modal-delete-btn {
|
||||
color: color-mix(in srgb, var(--destructive, #ef4444) 85%, var(--foreground));
|
||||
border-color: color-mix(in srgb, var(--destructive, #ef4444) 30%, transparent);
|
||||
}
|
||||
|
||||
.track-modal-delete-btn:hover {
|
||||
background: color-mix(in srgb, var(--destructive, #ef4444) 10%, transparent);
|
||||
color: var(--destructive, #ef4444);
|
||||
}
|
||||
|
||||
.track-modal-confirm {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: color-mix(in srgb, var(--destructive, #ef4444) 8%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--destructive, #ef4444) 25%, transparent);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.track-modal-confirm-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Error */
|
||||
.track-modal-error {
|
||||
margin: 0 20px 14px 20px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--destructive, #ef4444) 10%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--destructive, #ef4444) 25%, transparent);
|
||||
color: var(--destructive, #ef4444);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.track-modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 14px 20px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: color-mix(in srgb, var(--foreground) 3%, transparent);
|
||||
}
|
||||
|
||||
.track-modal-run-btn {
|
||||
background: var(--track-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.track-modal-run-btn:hover {
|
||||
background: color-mix(in srgb, var(--track-accent) 85%, black);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue