diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index d997f246..5a6e37f0 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -46,6 +46,12 @@ 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. @@ -773,11 +779,48 @@ export function setupIpcHandlers() { 'voice:synthesize': async (_event, args) => { return voice.synthesizeSpeech(args.text); }, - // Track handler + // 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); + 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(); diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 0d1aaaa9..31881fe0 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -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() { /> + = { + '* * * * *': '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 + if (icon === 'calendar' || icon === 'target') return + return +} + +// --------------------------------------------------------------------------- +// Modal +// --------------------------------------------------------------------------- + +type Tab = 'what' | 'when' | 'event' | 'details' + +export function TrackModal() { + const [open, setOpen] = useState(false) + const [detail, setDetail] = useState(null) + const [yaml, setYaml] = useState('') + const [loading, setLoading] = useState(false) + const [activeTab, setActiveTab] = useState('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(null) + const textareaRef = useRef(null) + + // Listen for the open event and seed modal state. + useEffect(() => { + const handler = (e: Event) => { + const ev = e as CustomEvent + 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 | 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) => { + 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 ( + + +
+
+
+ +
+
+ + + {trackId || 'Track'} + + + + {scheduleSummary.text} + {eventMatchCriteria && triggerType === 'scheduled' && ( + · also event-driven + )} + + +
+
+
+ +
+
+ + {/* Tabs */} +
+ {shown.map(tab => ( + + ))} +
+ + {/* Body */} +
+ {loading &&
Loading latest…
} + + {activeTab === 'what' && ( +
+ {instruction + ? {instruction} + : No instruction set.} +
+ )} + + {activeTab === 'when' && schedule && ( +
+
+ + {scheduleSummary.text} +
+
+
Type
{schedule.type}
+ {schedule.type === 'cron' && ( + <> +
Expression
{schedule.expression}
+ + )} + {schedule.type === 'window' && ( + <> +
Expression
{schedule.cron}
+
Window
{schedule.startTime} – {schedule.endTime}
+ + )} + {schedule.type === 'once' && ( + <> +
Runs at
{formatDateTime(schedule.runAt)}
+ + )} +
+
+ )} + + {activeTab === 'event' && ( +
+ {eventMatchCriteria + ? {eventMatchCriteria} + : No event matching set.} +
+ )} + + {activeTab === 'details' && ( +
+
+
Track ID
{trackId}
+
File
{detail.filePath}
+
Status
{active ? 'Active' : 'Paused'}
+ {lastRunAt && (<> +
Last run
{formatDateTime(lastRunAt)}
+ )} + {lastRunId && (<> +
Run ID
{lastRunId}
+ )} + {lastRunSummary && (<> +
Summary
{lastRunSummary}
+ )} +
+
+ )} + + {/* Advanced (raw YAML) — all tabs */} +
+ + {showAdvanced && ( +
+