diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 74388f65..d997f246 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -44,6 +44,8 @@ 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'; /** * Convert markdown to a styled HTML document for PDF/DOCX export. @@ -362,6 +364,19 @@ export async function startServicesWatcher(): Promise { }); } +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 +773,11 @@ export function setupIpcHandlers() { 'voice:synthesize': async (_event, args) => { return voice.synthesizeSpeech(args.text); }, + // Track handler + 'track:run': async (_event, args) => { + const result = await triggerTrackUpdate(args.trackId, args.filePath); + return { success: !result.error, summary: result.summary ?? undefined, error: result.error }; + }, // Billing handler 'billing:getInfo': async () => { return await getBillingInfo(); diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index 42c9f3fd..94ed9d53 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -4,6 +4,7 @@ import { setupIpcHandlers, startRunsWatcher, startServicesWatcher, + startTracksWatcher, startWorkspaceWatcher, stopRunsWatcher, stopServicesWatcher, @@ -22,6 +23,7 @@ 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 { initConfigs } from "@x/core/dist/config/initConfigs.js"; import started from "electron-squirrel-startup"; import { execSync, exec, execFileSync } from "node:child_process"; @@ -228,6 +230,9 @@ app.whenReady().then(async () => { // start services watcher startServicesWatcher(); + // start tracks watcher + startTracksWatcher(); + // start gmail sync initGmailSync(); diff --git a/apps/x/apps/renderer/package.json b/apps/x/apps/renderer/package.json index b9990e14..4bb837c9 100644 --- a/apps/x/apps/renderer/package.json +++ b/apps/x/apps/renderer/package.json @@ -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": { diff --git a/apps/x/apps/renderer/src/components/markdown-editor.tsx b/apps/x/apps/renderer/src/components/markdown-editor.tsx index ee1f6033..33e9b507 100644 --- a/apps/x/apps/renderer/src/components/markdown-editor.tsx +++ b/apps/x/apps/renderer/src/components/markdown-editor.tsx @@ -9,6 +9,7 @@ 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 { ImageBlockExtension } from '@/extensions/image-block' import { EmbedBlockExtension } from '@/extensions/embed-block' import { ChartBlockExtension } from '@/extensions/chart-block' @@ -638,6 +639,7 @@ export const MarkdownEditor = forwardRef } + deleteNode: () => void + updateAttributes: (attrs: Record) => void + extension: { options: { notePath?: string } } +}) { + const raw = node.attrs.data as string + const [expanded, setExpanded] = useState(false) + const [activeTab, setActiveTab] = useState('instruction') + const [editingRaw, setEditingRaw] = useState(false) + const [rawDraft, setRawDraft] = useState('') + const textareaRef = useRef(null) + + const track = useMemo | null>(() => { + try { + return TrackBlockSchema.parse(parseYaml(raw)) + } catch { return null } + }, [raw]) as z.infer | null; + + const trackId = track?.trackId ?? '' + const instruction = track?.instruction ?? '' + const matchCriteria = track?.matchCriteria ?? '' + const active = track?.active ?? true + const lastRunAt = track?.lastRunAt ?? '' + const lastRunId = track?.lastRunId ?? '' + const lastRunSummary = track?.lastRunSummary ?? '' + const notePath = extension.options.notePath + const trackFilePath = notePath?.replace(/^knowledge\//, '') ?? '' + + // Track run status from the global hook + const allTrackStatus = useTrackStatus() + const runState = allTrackStatus.get(`${track.trackId}:${trackFilePath}`) ?? { status: 'idle' as const } + const runStatus = runState.status + const runSummary = runState.summary ?? runState.error ?? null + + useEffect(() => { + if (editingRaw && textareaRef.current) { + textareaRef.current.focus() + textareaRef.current.setSelectionRange( + textareaRef.current.value.length, + textareaRef.current.value.length, + ) + } + }, [editingRaw]) + + const handleStartEdit = () => { + setRawDraft(raw) + setEditingRaw(true) + } + + const handleSaveRaw = () => { + updateAttributes({ data: rawDraft }) + setEditingRaw(false) + } + + const handleCancelEdit = () => { + setEditingRaw(false) + } + + const handleRun = useCallback(async (e: React.MouseEvent) => { + e.stopPropagation() + if (runStatus === 'running' || !trackId || !trackFilePath) return + try { + await window.ipc.invoke('track:run', { trackId, filePath: trackFilePath }) + } catch (err) { + console.error('[TrackBlock] Run failed:', err) + } + }, [runStatus, trackId, trackFilePath]) + + const tabs: { key: Tab; label: string }[] = [ + { key: 'instruction', label: 'Instruction' }, + { key: 'criteria', label: 'Match Criteria' }, + { key: 'metadata', label: 'Metadata' }, + ] + + const isRunning = runStatus === 'running' + + return ( + +
e.stopPropagation()} + > + + + {/* Collapsed view */} +
setExpanded(!expanded)} + > + + + Track + {!active && paused} + {truncate(instruction, 60)} + {lastRunAt && !isRunning && ( + + + {formatDateTime(lastRunAt)} + + )} + {isRunning && ( + + + Running… + + )} + +
+ + {/* Status bar */} + {runSummary && runStatus !== 'running' && ( +
+ {runSummary} +
+ )} + + {/* Expanded view */} + {expanded && ( +
+
+ {tabs.map(tab => ( + + ))} + +
+ + {editingRaw ? ( +
+